iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0

前言

我們昨天完成了利用 Supabase 的使用者登入註冊系統,坦白說這種第三方認證服務確實挺方便的,通常也都提供其他OAuth的整合服務,讓幾年前還不太好做的認證系統變得非常簡單方便,只是需要花一點點時間閱讀官方文件並做一些專案設置即可。當然,實際上我們目前的登入系統相當的簡陋,還有許多加強的空間,但現階段夠我們用了!

目前為止我們已經建立了practice_records資料表,讓該資料表與user.id連動並建立了登入驗證系統,下一步就是在資料庫中塞入真正的整合資料,讓使用者每一次練習都會確實留下紀錄,方便日後回顧之餘,也是我們未來主控台顯示資料的基礎,馬上就開始吧!

今日目標

  • 身分識別:在 /api/interview/evaluate API 中,安全地獲取當前登入的使用者 ID。
  • 架構設計:從一開始就設計一個能區分「首次評估」與「後續對話」的穩健架構,避免髒數據產生。
  • 資料入庫:在不影響 Streaming 體驗的前提下,將 AI 評估結果與使用者 ID 一併寫入 practice_records 資料表。
  • 修改prompt.ts讓 AI 面試官也能處理後續問答。

Step 1: 為真實對話場景設計架構

在動手寫程式碼之前,我們先思考一個問題:「我們究竟該什麼時候寫入練習紀錄?」,你可能會覺得這問題有些奇怪,「阿不就 AI 回答完之後把 AI 的回饋與使用者的回答寫入資料庫就好了嗎?」。這樣的回答對也不對,主要是因為要考慮我們目前的資料結構設計,想像一下真實的面試場景:對話並不是「一問一答」就結束了。使用者在收到 AI 的評估後,很可能會追問:「可以再解釋詳細一點嗎?」或「還有其他解法嗎?」。
如果我們的系統不區分這些互動,可能會把使用者的「追問」也當成一次新的「回答」存入資料庫,這顯然是錯誤的。因此,我們必須從一開始就設計一個能區分**「需要被記錄的首次回答」和「不需記錄的後續追問」**的機制。

最簡單有效的方法,就是在前端維護一個狀態,並在呼叫 API 時傳遞一個標記。

Step 2: 前端修改 (InterviewPage.tsx)

我們在前端頁面加入一個 isEvaluated 狀態。一旦首次評估成功返回,我們就將其標記為 true,代表這次面試的核心評估環節已結束,後續的對話再出新的問題之前都屬於使用者追問的過程,不列入練習紀錄, AI 也不需要給回饋。

// app/interview/[sessionId]/page.tsx
'use client';

import { useState, useEffect, useRef } from 'react';
// ... 其他 imports

export default function InterviewPage() {
  // ... 其他狀態
  const [isEvaluated, setIsEvaluated] = useState(false); // <-- 新增狀態

  const handleSubmit = async () => {
    // ...
    try {
      // ...
      const response = await fetch('/api/interview/evaluate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          // ... 其他 body 內容
          isFollowUp: isEvaluated, // <-- 關鍵新增:將狀態傳給後端
        }),
        signal: abortControllerRef.current?.signal,
      });

      // ... 處理 response ...

      while (true) {
        const { done, value } = await reader.read();
        if (done) {
          try {
            // ... 解析 finalJson ...
            setIsEvaluated(true); // <-- 關鍵新增:首次評估成功後,更新狀態
          } catch (e) {
            // ... 錯誤處理 ...
          }
          break;
        }
        // ... 處理 streaming ...
      }
    } catch (error) {
      // ... 錯誤處理 ...
    }
  };

  // ... 剩下的元件 JSX
}

Step 3: 後端 API 準備 (/api/interview/evaluate/route.ts)

有了前端的 isFollowUp 標記,我們的後端 API 現在就能夠像一個聰明的守衛,決定是否要執行「寫入資料庫」這個重要操作。
不過由於我們在之前的重構中就將路由的邏輯整理得很乾淨了,目前/api/interview/evaluate/route.ts與 Stream 相關的邏輯只剩這一行。

const stream = await generateContentStream(finalPrompt);

但這可就有點麻煩了,我們的「寫入資料庫」邏輯,需要等到 Gemini 的串流完全結束後,拿到完整的 JSON 才能執行。但現在,整個串流過程被封裝在一個通用的 gemini.ts 函式裡,這個通用函式本身不應該、也不知道任何關於「寫入資料庫」的事情。
我們該如何解決這個問題?我們不能把 user, questionId 等資訊全都傳入 generateContentStream,那會破壞它的通用性,這些本身也與該函數無關,硬是塞進去只會讓這個重構後的函數變得混亂。

要解決的方法有很多種,我這邊選擇用 callback 函數的概念來處理,也就是讓我們的generateContentStream接收一個新的函數做為參數,在該 callback 函數中我們有辦法傳入isFollowUp這個值,這麼一來我們就可以順利的在generateContentStream中達到我們預期的行為了。
首先請你打開app/lib/gemini.ts檔案,我們先來修改generateContentStream函數,你可以完整貼上以下的內容覆蓋原本的函數:

// app/lib/gemini.ts
/**
 * 使用 Gemini 生成串流回應
 * @param prompt 完整的 prompt 文字
 * @param onComplete 串流結束後的回呼函式,用於處理完整的 JSON 回應
 * @returns ReadableStream 用於串流回應
 */
export async function generateContentStream(
  prompt: string,
  onComplete?: (json: string) => void
): Promise<ReadableStream> {
  const contents: Content[] = [{ parts: [{ text: prompt }] }];

  const result = await genAI.models.generateContentStream({
    model: 'gemini-2.5-flash',
    contents: contents,
    config: {
      responseMimeType: 'application/json',
    },
  });

  return new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();
      let accumulatedJson = '';
      for await (const chunk of result) {
        const text = chunk.text;
        if (text) {
          controller.enqueue(encoder.encode(text));
          accumulatedJson += text;
        }
      }

      // 【核心修改】串流結束後,檢查並執行 onComplete 回呼
      if (onComplete) {
        try {
          onComplete(accumulatedJson);
        } catch (e) {
          // 在伺服器端紀錄回呼函式執行時的錯誤
          console.error('Error executing onComplete callback:', e);
        }
      }
      controller.close();
    },
  });
}

接著請打開 app/api/interview/evaluate/route.ts,我們將整合身分驗證和這個新的判斷邏輯。

// app/api/interview/evaluate/route.ts
import {
  createAuthClient,
  supabase as adminSupabase,
} from '@/app/lib/supabase/server';
// ... 其他 imports

export async function POST(request: Request) {
  // 1. 驗證使用者身分
  const supabase = createServerClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) {
    return new Response('Unauthorized', { status: 401 });
  }

  // 2. 取得 isFollowUp 旗標
  const { questionId, answer, history, isFollowUp } = await request.json();

  // ... RAG, Judge0, Gemini prompt 準備 ...
  
  // 3. 執行串流與條件式寫入
  const stream = await generateContentStream(
      finalPrompt,
      async (fullJson) => {
        // 這個函式會在 gemini.ts 中被呼叫
        // 只有在不是追問的情況下,才執行資料庫寫入
        if (!isFollowUp) {
          try {
            const finalEvaluation = JSON.parse(fullJson);
            const recordToInsert = {
              user_id: user.id,
              question_id: questionId,
              user_answer: answer,
              evaluation: finalEvaluation,
              score: finalEvaluation.score,
            };

            const { error: insertError } = await adminSupabase
              .from('practice_records')
              .insert(recordToInsert);

            if (insertError) {
              console.error('Error in onComplete DB write:', insertError);
            }
          } catch (e) {
            console.error('Failed to parse or insert record in onComplete:', e);
          }
        }
      }
    );

  return new Response(stream, { headers: { 'Content-Type': 'application/json; charset=utf-8' } });
}

程式碼摘要說明:

  • 引入驗證用的createAuthClient以及有修改資料庫權限的supabase實體。
  • 取得從前端傳來的isFolowUp flag讓我們能做後續的判斷。
  • 將 callback函數傳入generateContentStream函數,其中我們在串流結束後,用 if (!isFollowUp) 作為一個「閘門」,只有首次提交(isFollowUp 為 false)的請求才能通過並寫入資料庫,任何後續的對話都會被這個閘門擋下,從而完美地保護了資料的正確性。

現在試著開啟開發伺服器

npm run dev

隨便用概念問答或是程式實作題目去做一個問題的回答,接著打開你 Supabase 的儀表板,你應該會看到確實有資料被寫入,我們的改動很順利的完成了!

圖1
圖1 :資料成功寫入

接著再隨便輸入一個訊息給 AI 並再次檢查資料庫,你會發現資料並沒有再度新增一筆,我們的isFollowUp邏輯也確實有用,但...有一點點不太對勁,如果你照著以上的操作你會發現個有趣的畫面,如下圖:

圖2
圖2 :非預期地追問畫面

明明我只是想與 AI 繼續討論,但他卻當作我還在回答問題,並給予了我評分,這很明顯不是我們想要的行為,最主要的原因還是在於我們當初在設計prompt.ts時還是以單次問答設計,並沒有考慮到後續使用者可能有其他問題想問或是討論,為了處理這樣的情況,我們需要對我們的system prompt和後端服務做一點加工。

Step 4: 處理追問的prompt設計

1. 更新 prompt.ts

請將以下的程式碼完整貼上到我們之前的prompt.ts檔案:

// 保持模板的獨立性,使其易於管理和修改
export const unifiedPromptTemplate = `<role>
You are a world-class senior frontend technical interviewer. Your tone should be professional, insightful, and supportive. Address the candidate directly as "你".
</role>
<task>
Your primary task is to determine if this is an initial evaluation or a follow-up conversation based on the <is_follow_up> flag.

---
**CASE 1: This is a FOLLOW-UP CONVERSATION (<is_follow_up> is true)**

- Your role is to continue the conversation naturally based on the <conversation_history>.
- Answer the candidate's follow-up question, clarify points, or provide further examples.
- Your response MUST be a single, valid JSON object following the <json_schema>.
- In your response:
  - Place your conversational reply into the \`summary\` field.
  - You MUST set \`score\`, \`grounded_evidence\`, \`pros\`, and \`cons\` to \`null\`.
  - You can optionally provide suggestions in the \`next_practice\` field.

---
**CASE 2: This is the INITIAL EVALUATION (<is_follow_up> is false)**

- Your role is to provide a comprehensive evaluation of the candidate's answer (<candidate_answer>).
- Your evaluation must be grounded in the evidence given.
- Then, determine the question type:
  - **If the question is conceptual**: Base your evaluation on <rag_context>. \`grounded_evidence\` MUST be \`null\`.
  - **If the question is a coding challenge**: Base your evaluation on <judge0_result>. \`grounded_evidence\` MUST be populated.
- Your response MUST be a single, valid JSON object following the <json_schema>.
---

Always answer in Traditional Chinese.
</task>

<json_schema>
{
  "summary": "string",
  "score": "number (1-5) | null",
  "grounded_evidence": { "tests_passed": "number|null", "tests_failed": "number|null", "stderr_excerpt": "string|null" } | null,
  "pros": "string[] | null",
  "cons": "string[] | null",
  "next_practice": "string[]"
}
</json_schema>

<is_follow_up>
\${isFollowUp}
</is_follow_up>
<conversation_history>
\${formattedHistory}
</conversation_history>
<question>
\${question}
</question>
<rag_context>
\${ragContext}
</rag_context>
<judge0_result>
\${judge0Result}
</judge0_result>
<candidate_answer>
\${userAnswer}
</candidate_answer>`;

// 【修改版】PromptContext 介面,增加 isFollowUp
interface PromptContext {
  isFollowUp: boolean;
  formattedHistory: string;
  question: string;
  ragContext: string;
  judge0Result: string;
  userAnswer: string;
}

/**
 * 根據上下文填充統一的 Prompt 模板。
 * @param context 包含所有需要填充的資訊的物件
 * @returns 填充完畢的最終 Prompt 字串
 */
// 【修改版】buildUnifiedPrompt 函式,替換 isFollowUp 並修正 placeholder 名稱
export function buildUnifiedPrompt(context: PromptContext): string {
  return unifiedPromptTemplate
    .replace(/\${isFollowUp}/g, String(context.isFollowUp))
    .replace(/\${formattedHistory}/g, context.formattedHistory)
    .replace(/\${question}/g, context.question)
    .replace(/\${ragContext}/g, context.ragContext)
    .replace(/\${judge0Result}/g, context.judge0Result)
    .replace(/\${userAnswer}/g, context.userAnswer);
}

我們加上isFollowUp的判斷邏輯,若視後續的追問,則除了訊息之外的內容都不需要回傳,其餘的邏輯則保持不變。

2. 在後端buildUnifiedPrompt傳入這個變數

下一步則是回到我們的evaluate api,在呼叫buildUnifiedPrompt函數的地方加入我們剛剛的變數,isFollowUp我們已經在前面的步驟中從前端的回應中取得了

const finalPrompt = buildUnifiedPrompt({
      isFollowUp, // 加入這個
      formattedHistory,
      question: question.question,
      ragContext,
      judge0Result: judge0ResultText,
      userAnswer: answer,
    });

3. 修改前端渲染的邏輯

再次回到app/interview/[sessionId]/page.tsx檔案,在串流結束的設置訊息的部分加一個邏輯。

setChatHistory((prevHistory) => {
              const newHistory = [...prevHistory];
              newHistory[newHistory.length - 1] = {
                role: 'ai',
                content: finalJson.summary || accumulatedResponse, 
                evaluation: isEvaluated ? null : finalJson, // 如果已經評估過,就不再傳evaluation去渲染
              };

這些都完成後再次追問, AI 面試官就可正常回應你追問的問題囉!

今日回顧

今天,我們讓 AI 面試官真正擁有了「記憶」使用者的能力,為後續的個人化功能打下了最堅實的基礎。

✅ 實現了身分綁定:我們成功在核心 API 中獲取了登入使用者的 ID。
✅ 設計了穩健架構:透過 isFollowUp 旗標,我們從一開始就讓系統能區分評估與對話,避免了髒數據。
✅ 完成了資料持久化:使用者的每一次練習評估,現在都會被安全、準確地記錄在 practice_records 資料表中。
✅ 修改了prompt.ts和前端渲染的邏輯,讓應用程式能正確地處理追問。

明日預告

資料已經開始源源不絕地寫入資料庫了。下一步,當然就是要把這些數據以有意義的方式展示給使用者看!明天 (Day 24),我們將打造一個個人化的練習詳情頁面,讓使用者可以回顧每一次面試的完整細節。

今日程式碼: https://github.com/windate3411/Itiron-2025-code/tree/day-23


上一篇
建立 Auth:整合 Supabase SSR
系列文
前端工程師的AI應用開發實戰:30天從Prompt到Production - 以打造AI前端面試官為例23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言